import path from "path";
import fs from "fs";
import assert from "assert";
import { ExecaChildProcess, ExecaError } from "execa";
import mime from "mime";
import _ from "lodash";
import { Disposable } from "vscode";
import { getAppCachesDir, getOldAppCachesDir } from "../utilities/common";
import { DeviceBase } from "./DeviceBase";
import { Preview } from "./preview";
import { Logger } from "../Logger";
import { exec, lineReader } from "../utilities/subprocess";
import { getAvailableIosRuntimes } from "../utilities/iosRuntimes";
import { BuildResult } from "../builders/BuildManager";
import { AppPermissionType } from "../common/Project";
import { EXPO_GO_BUNDLE_ID, fetchExpoLaunchDeeplink, getExpoVersion } from "../builders/expoGo";
import { IOSBuildResult } from "../builders/buildIOS";
import { OutputChannelRegistry } from "../project/OutputChannelRegistry";
import { Output } from "../common/OutputChannel";
import {
  DevicePlatform,
  DevicesByType,
  DeviceSettings,
  DeviceType,
  InstallationError,
  InstallationErrorReason,
  IOSDeviceInfo,
  IOSRuntimeInfo,
  Locale,
} from "../common/State";
import { checkXcodeExists } from "../utilities/checkXcodeExists";
import { Platform } from "../utilities/platform";
import { DeviceAlreadyUsedError } from "./DeviceAlreadyUsedError";
import { DevicesProvider } from "./DevicesProvider";
import { StateManager } from "../project/StateManager";

interface SimulatorInfo {
  availability?: string;
  state?: string;
  isAvailable?: boolean;
  name: string;
  udid: string;
  version?: string;
  displayName: string;
  availabilityError?: string;
  type?: "simulator" | "device" | "catalyst";
  booted?: boolean;
  lastBootedAt?: string;
  deviceTypeIdentifier?: string;
}

interface SimulatorData {
  devices: { [runtimeID: string]: SimulatorInfo[] };
}

type PrivacyServiceName =
  | "all"
  | "calendar"
  | "contacts-limited"
  | "contacts"
  | "location"
  | "location-always"
  | "photos-add"
  | "photos"
  | "media-library"
  | "microphone"
  | "motion"
  | "reminders"
  | "siri";

export class IosSimulatorDevice extends DeviceBase {
  private runningAppProcess: ExecaChildProcess | undefined;

  constructor(
    deviceSettings: DeviceSettings,
    private readonly deviceUDID: string,
    private readonly _deviceInfo: IOSDeviceInfo,
    private readonly outputChannelRegistry: OutputChannelRegistry
  ) {
    super(deviceSettings);
  }

  public get platform(): DevicePlatform {
    return DevicePlatform.IOS;
  }

  public get deviceInfo(): IOSDeviceInfo {
    return this._deviceInfo;
  }

  public get id(): string {
    return this.deviceUDID;
  }

  public get lockFilePath(): string {
    const deviceSetLocation = getDeviceSetLocation(this.deviceUDID);
    const pidFile = path.join(deviceSetLocation, this.deviceUDID, "lock.pid");
    return pidFile;
  }

  private get nativeLogsOutputChannel() {
    return this.outputChannelRegistry.getOrCreateOutputChannel(Output.IosDevice);
  }
  private get maestroLogsOutputChannel() {
    return this.outputChannelRegistry.getOrCreateOutputChannel(Output.MaestroIos);
  }

  public dispose() {
    super.dispose();
    this.runningAppProcess?.cancel();
    return exec(
      "xcrun",
      ["simctl", "--set", getOrCreateDeviceSet(this.deviceUDID), "shutdown", this.deviceUDID],
      {
        reject: false, // we ignore the error here, as the shutdown command may fail if the device is already shutdown
      }
    );
  }

  public async reboot() {
    super.reboot();
    this.runningAppProcess?.cancel();
    await exec(
      "xcrun",
      ["simctl", "--set", getOrCreateDeviceSet(this.deviceUDID), "shutdown", this.deviceUDID],
      {
        reject: false, // we ignore the error here, as the shutdown command may fail if the device is already shutdown
      }
    );

    await this.internalBootDevice();
  }

  public setUpKeyboard() {
    this.preview?.setUpKeyboard();
  }

  private async internalBootDevice() {
    const deviceSetLocation = getOrCreateDeviceSet(this.deviceUDID);
    try {
      await exec("xcrun", ["simctl", "--set", deviceSetLocation, "boot", this.deviceUDID], {
        allowNonZeroExit: true,
      });
    } catch (e) {
      const isAlreadyBooted = (e as ExecaError).stderr?.includes("current state: Booted");
      if (isAlreadyBooted) {
        Logger.debug("Device already booted");
      } else {
        throw e;
      }
    }
  }

  async bootDevice() {
    if (await this.shouldUpdateLocale(this.deviceSettings.locale)) {
      await this.changeLocale(this.deviceSettings.locale);
    }

    await this.internalBootDevice();

    await this.changeSettings(this.deviceSettings);
  }

  private async shouldUpdateLocale(locale: Locale): Promise<boolean> {
    const deviceSetLocation = getOrCreateDeviceSet(this.deviceUDID);
    const deviceLocale = await exec("/usr/libexec/PlistBuddy", [
      "-c",
      `print :AppleLocale`,
      path.join(
        deviceSetLocation,
        this.deviceUDID,
        "data",
        "Library",
        "Preferences",
        ".GlobalPreferences.plist"
      ),
    ]);
    if (deviceLocale.stdout === locale) {
      return false;
    }
    return true;
  }

  async changeSettings(settings: DeviceSettings): Promise<boolean> {
    let shouldRestart = false;
    const deviceSetLocation = getOrCreateDeviceSet(this.deviceUDID);

    if (await this.shouldUpdateLocale(settings.locale)) {
      shouldRestart = true;
      this.changeLocale(settings.locale);
    }

    await exec("xcrun", [
      "simctl",
      "--set",
      deviceSetLocation,
      "ui",
      this.deviceUDID,
      "appearance",
      settings.appearance,
    ]);
    await exec("xcrun", [
      "simctl",
      "--set",
      deviceSetLocation,
      "ui",
      this.deviceUDID,
      "content_size",
      convertToSimctlSize(settings.contentSize),
    ]);
    if (settings.location.isDisabled) {
      await exec("xcrun", [
        "simctl",
        "--set",
        deviceSetLocation,
        "location",
        this.deviceUDID,
        "clear",
      ]);
    } else {
      // NOTE: because `simctl` does not accept `"0,0"` location coordinates, we pass smallest accepted values instead whenever the passed values would be rounded down to `"0,0"`
      let latitude = settings.location.latitude.toString();
      let longitude = settings.location.longitude.toString();
      if (latitude === "0" && longitude === "0") {
        latitude = "0.0001";
        longitude = "0.0001";
      }

      await exec("xcrun", [
        "simctl",
        "--set",
        deviceSetLocation,
        "location",
        this.deviceUDID,
        "set",
        `${latitude},${longitude}`,
      ]);
    }
    await exec("xcrun", [
      "simctl",
      "--set",
      deviceSetLocation,
      "spawn",
      this.deviceUDID,
      "notifyutil",
      "-s",
      "com.apple.BiometricKit.enrollmentChanged",
      settings.hasEnrolledBiometrics ? "1" : "0",
    ]);
    await exec("xcrun", [
      "simctl",
      "--set",
      deviceSetLocation,
      "spawn",
      this.deviceUDID,
      "notifyutil",
      "-p",
      "com.apple.BiometricKit.enrollmentChanged",
    ]);

    return shouldRestart;
  }

  async sendBiometricAuthorization(isMatch: boolean) {
    const deviceSetLocation = getOrCreateDeviceSet(this.deviceUDID);
    await exec("xcrun", [
      "simctl",
      "--set",
      deviceSetLocation,
      "spawn",
      this.deviceUDID,
      "notifyutil",
      "-p",
      isMatch
        ? "com.apple.BiometricKit_Sim.fingerTouch.match"
        : "com.apple.BiometricKit_Sim.fingerTouch.nomatch",
    ]);
  }

  public async sendClipboard(text: string) {
    const deviceSetLocation = getOrCreateDeviceSet(this.deviceUDID);
    await exec("xcrun", ["simctl", "--set", deviceSetLocation, "pbcopy", this.deviceUDID], {
      input: text,
    });
  }

  public async getClipboard() {
    const deviceSetLocation = getOrCreateDeviceSet(this.deviceUDID);
    const { stdout } = await exec("xcrun", [
      "simctl",
      "--set",
      deviceSetLocation,
      "pbpaste",
      this.deviceUDID,
    ]);

    return stdout;
  }

  private async changeLocale(newLocale: Locale): Promise<boolean> {
    const deviceSetLocation = getOrCreateDeviceSet(this.deviceUDID);
    const languageCode = newLocale.match(/([^_-]*)/)![1];
    await exec("/usr/libexec/PlistBuddy", [
      "-c",
      `set :AppleLanguages:0 ${languageCode}`,
      "-c",
      `set :AppleLocale ${newLocale}`,
      path.join(
        deviceSetLocation,
        this.deviceUDID,
        "data",
        "Library",
        "Preferences",
        ".GlobalPreferences.plist"
      ),
    ]);
    return true;
  }

  async configureMetroPort(bundleID: string, metroPort: number) {
    const deviceSetLocation = getOrCreateDeviceSet(this.deviceUDID);
    const { stdout: appDataLocation } = await exec("xcrun", [
      "simctl",
      "--set",
      deviceSetLocation,
      "get_app_container",
      this.deviceUDID,
      bundleID,
      "data",
    ]);
    const userDefaultsLocation = path.join(
      appDataLocation,
      "Library",
      "Preferences",
      `${bundleID}.plist`
    );
    Logger.debug(`Defaults location ${userDefaultsLocation}`);
    await exec(
      "/usr/libexec/PlistBuddy",
      [
        "-c",
        "Delete :RCT_jsLocation",
        "-c",
        `Add :RCT_jsLocation string localhost:${metroPort}`,
        userDefaultsLocation,
      ],
      { allowNonZeroExit: true, reject: false }
    );

    // Simulator may keep the defaults in memory with cfprefsd, we restart the daemon to make sure
    // it reads the latest values from disk.
    // We'd normally try to use defaults command that would write the updates via the daemon, however
    // for some reason that doesn't work with custom device sets.
    await exec(
      "xcrun",
      [
        "simctl",
        "--set",
        deviceSetLocation,
        "spawn",
        this.deviceUDID,
        "launchctl",
        "stop",
        "com.apple.cfprefsd.xpc.daemon",
      ],
      { reject: false }
    );

    // Restarting cfprefsd breaks the SpringBoard process which in particular controls things like
    // the appearance settings. We need to restart it following the cfprefsd reset.
    await exec(
      "xcrun",
      [
        "simctl",
        "--set",
        deviceSetLocation,
        "spawn",
        this.deviceUDID,
        "launchctl",
        "kickstart",
        "-k",
        "system/com.apple.SpringBoard",
      ],
      { reject: false }
    );

    // Restarting SpringBoard process breaks the backboardd which in particular controls things like
    // the rotation settings. We need to restart it following the cfprefsd springboard reset.
    await exec(
      "xcrun",
      [
        "simctl",
        "--set",
        deviceSetLocation,
        "spawn",
        this.deviceUDID,
        "launchctl",
        "kickstart",
        "-k",
        "system/com.apple.backboardd",
      ],
      { reject: false }
    );
  }

  async terminateApp(bundleID: string) {
    const deviceSetLocation = getOrCreateDeviceSet(this.deviceUDID);

    // Terminate the app if it's running:
    try {
      await exec(
        "xcrun",
        ["simctl", "--set", deviceSetLocation, "terminate", this.deviceUDID, bundleID],
        { allowNonZeroExit: true }
      );
    } catch (e) {
      // terminate will exit with non-zero code when the app wasn't running. we ignore this error
    }
  }

  /**
   * This function terminates any running applications. Might be useful when you launch a new application
   * before terminating the previous one.
   */
  async terminateAnyRunningApplications() {
    const deviceSetLocation = getOrCreateDeviceSet(this.deviceUDID);
    const { stdout } = await exec("xcrun", [
      "simctl",
      "--set",
      deviceSetLocation,
      "listapps",
      this.deviceUDID,
    ]);

    const regex = /ApplicationType = User;\s*[^{}]*?\bCFBundleIdentifier = "([^"]+)/g;

    const matches = [];
    let match;
    while ((match = regex.exec(stdout)) !== null) {
      matches.push(match[1]);
    }

    await Promise.all(matches.map(async (e) => await this.terminateApp(e)));
  }

  async launchWithBuild(build: IOSBuildResult, launchArguments: string[]) {
    const deviceSetLocation = getOrCreateDeviceSet(this.deviceUDID);

    await this.terminateAnyRunningApplications();

    if (this.runningAppProcess) {
      this.runningAppProcess.kill(9);
    }

    this.nativeLogsOutputChannel.clear();

    const launchAppArgs = [
      "simctl",
      "--set",
      deviceSetLocation,
      "launch",
      "--console",
      "--terminate-running-process",
      this.deviceUDID,
      build.bundleID,
      ...launchArguments,
    ];

    this.runningAppProcess = exec("xcrun", launchAppArgs);

    lineReader(this.runningAppProcess).onLineRead((line) =>
      this.nativeLogsOutputChannel?.appendLine(line)
    );
  }

  async launchWithExpoDeeplink(bundleID: string, expoDeeplink: string) {
    // For Expo dev-client and Expo Go setup, we use deeplink to launch the app. For this approach to work we do the following:
    // 1. Add the deeplink to the scheme approval list via defaults
    // 2. Terminate any app if it's running
    // 3. Open the deeplink
    const deviceSetLocation = getOrCreateDeviceSet(this.deviceUDID);

    // Add the deeplink to the scheme approval list:
    const schema = new URL(expoDeeplink).protocol.slice(0, -1);
    await exec("xcrun", [
      "simctl",
      "--set",
      deviceSetLocation,
      "spawn",
      this.deviceUDID,
      "defaults",
      "write",
      "com.apple.launchservices.schemeapproval",
      `com.apple.CoreSimulator.CoreSimulatorBridge-->${schema}`,
      "-string",
      bundleID,
    ]);

    await this.terminateAnyRunningApplications();

    // Use openurl to open the deeplink:
    await exec("xcrun", [
      "simctl",
      "--set",
      deviceSetLocation,
      "openurl",
      this.deviceUDID,
      expoDeeplink,
      // TODO: disableOnboarding param causes error while launching
      // + "&disableOnboarding=1", // disable onboarding dialog via deeplink query param
    ]);
  }

  async launchApp(
    build: BuildResult,
    metroPort: number,
    _devtoolsPort: number | undefined,
    launchArguments: string[],
    appRoot: string
  ) {
    if (build.platform !== DevicePlatform.IOS) {
      throw new Error("Invalid platform");
    }
    const deepLinkChoice = build.bundleID === EXPO_GO_BUNDLE_ID ? "expo-go" : "expo-dev-client";
    const expoDeeplink = await fetchExpoLaunchDeeplink(metroPort, "ios", deepLinkChoice);
    if (expoDeeplink) {
      Logger.info("Expo deeplink detected", expoDeeplink);
      const expoVersion = getExpoVersion(appRoot);
      // parseInt will return int corresponding to the major version
      if (parseInt(expoVersion) >= 52) {
        // for Expo SDK 52+ we can use the --initialUrl parameter to pass to the launched process on iOS
        // this option allows us to avoid launching via deeplink and this way to avoid linking permission
        // overrides as well as allows us to capture the process logs into the output channel.
        assert(
          !launchArguments.includes("--initialUrl"),
          "Launch arguments include --initialUrl parameter. This parameter is not supported for expo-go and dev-client setups. Please remove it from the launch arguments."
        );
        Logger.info(
          "Launching the app",
          build.bundleID,
          "using --initialUrl parameter",
          expoDeeplink
        );

        const launchArgsWithInitialUrl = [...launchArguments, "--initialUrl", expoDeeplink];
        await this.launchWithBuild(build, launchArgsWithInitialUrl);
      } else {
        // for older Expo SDKs we need to launch via deeplink
        Logger.info("Launching the app", build.bundleID, "via deeplink", expoDeeplink);
        this.launchWithExpoDeeplink(build.bundleID, expoDeeplink);
      }
    } else {
      Logger.info("Launching app using bundle ID without deeplink");
      await this.configureMetroPort(build.bundleID, metroPort);
      await this.launchWithBuild(build, launchArguments);
    }
  }

  async locateInstalledAppBuildHashFile(build: IOSBuildResult) {
    const deviceSetLocation = getOrCreateDeviceSet(this.deviceUDID);
    try {
      const { stdout: appContainerLocation } = await exec("xcrun", [
        "simctl",
        "--set",
        deviceSetLocation,
        "get_app_container",
        this.deviceUDID,
        build.bundleID,
        "app",
      ]);
      return path.join(appContainerLocation, ".radonide.buildhash");
    } catch (error) {
      return undefined;
    }
  }

  async checkInstalledAppBuildHashFile(build: IOSBuildResult) {
    const buildHashFileLocation = await this.locateInstalledAppBuildHashFile(build);
    if (buildHashFileLocation === undefined) {
      return null;
    }
    try {
      const buildHash = await fs.promises.readFile(buildHashFileLocation, "utf8");
      return buildHash === build.buildHash;
    } catch (error) {
      return null;
    }
  }

  async updateInstalledAppBuildHash(build: IOSBuildResult) {
    const buildHashFileLocation = await this.locateInstalledAppBuildHashFile(build);
    if (buildHashFileLocation !== undefined) {
      await fs.promises.writeFile(buildHashFileLocation, build.buildHash);
    }
  }

  async installApp(build: BuildResult, forceReinstall: boolean) {
    if (build.platform !== DevicePlatform.IOS) {
      throw new InstallationError("Invalid platform", InstallationErrorReason.InvalidPlatform);
    }

    try {
      const startTime = performance.now();
      const deviceSetLocation = getOrCreateDeviceSet(this.deviceUDID);
      if (forceReinstall) {
        try {
          await exec(
            "xcrun",
            ["simctl", "--set", deviceSetLocation, "uninstall", this.deviceUDID, build.bundleID],
            { allowNonZeroExit: true }
          );
        } catch (e) {
          Logger.error("Error while uninstalling will be ignored", e);
        }
      } else {
        const isInstalled = await this.checkInstalledAppBuildHashFile(build);
        if (isInstalled) {
          const skipInstallDurationSec = (performance.now() - startTime) / 1000;
          Logger.info(
            `App is already installed, skipping installation. Took ${skipInstallDurationSec.toFixed(
              2
            )} sec.`
          );
          return;
        }
      }

      await exec("xcrun", [
        "simctl",
        "--set",
        deviceSetLocation,
        "install",
        this.deviceUDID,
        build.appPath,
      ]);
      const installDurationSec = (performance.now() - startTime) / 1000;
      Logger.info(`App installed. Took ${installDurationSec.toFixed(2)} sec.`);
      await this.updateInstalledAppBuildHash(build);
    } catch (e) {
      const message =
        typeof e === "object" && e !== null && "message" in e ? String(e.message) : String(e);
      throw new InstallationError(message, InstallationErrorReason.Unknown);
    }
  }

  async resetAppPermissions(appPermission: AppPermissionType, build: BuildResult) {
    if (build.platform !== DevicePlatform.IOS) {
      throw new Error("Invalid platform");
    }
    const privacyServiceName: PrivacyServiceName = appPermission;
    await exec("xcrun", [
      "simctl",
      "--set",
      getOrCreateDeviceSet(this.deviceUDID),
      "privacy",
      this.deviceUDID,
      "reset",
      privacyServiceName,
      build.bundleID,
    ]);
    return false;
  }

  async sendDeepLink(link: string, build: BuildResult) {
    if (build.platform !== DevicePlatform.IOS) {
      throw new Error("Invalid platform");
    }

    await exec("xcrun", [
      "simctl",
      "--set",
      getOrCreateDeviceSet(this.deviceUDID),
      "openurl",
      this.deviceUDID,
      link,
    ]);
  }

  async forwardDevicePort(port: number) {
    // iOS Simulators do not require port forwarding
  }

  makePreview(licenseToken?: string): Preview {
    const args = [
      "ios",
      "--id",
      this.deviceUDID,
      "--device-set",
      getOrCreateDeviceSet(this.deviceUDID),
    ];
    if (licenseToken !== undefined) {
      args.push("-t", licenseToken);
    }
    return new Preview(args);
  }

  public async sendFile(filePath: string) {
    const fileExtension = path.extname(filePath);
    if (SUPPORTED_FILE_URL_EXTS.includes(fileExtension)) {
      await exec("xcrun", [
        "simctl",
        "--set",
        getOrCreateDeviceSet(this.deviceUDID),
        "openurl",
        this.deviceUDID,
        `file://${filePath}`,
      ]);
      return { canSafelyRemove: false };
    }
    if (!isMediaFile(filePath)) {
      throw new Error(
        `Unsupported file type "${fileExtension}". ` +
          `Only images, video files and SSL certificates are currently supported.`
      );
    }
    const args = [
      "simctl",
      "--set",
      getOrCreateDeviceSet(this.deviceUDID),
      "addmedia",
      this.deviceUDID,
      filePath,
    ];
    await exec("xcrun", args);
    return { canSafelyRemove: true };
  }
}

const SUPPORTED_FILE_URL_EXTS = [
  // SSL Certificates:
  ".cer",
  ".pem",
];

function isMediaFile(filePath: string): boolean {
  const type = mime.lookup(filePath);
  return type.startsWith("image/") || type.startsWith("video/");
}

export async function getNewestAvailableIosRuntime() {
  const runtimesData = await getAvailableIosRuntimes();

  // sort available runtimes by version
  runtimesData.sort((a, b) => (a.version.localeCompare(b.version) ? -1 : 1));

  // pick the newest runtime
  return runtimesData[0];
}

export async function removeIosRuntimes(runtimeIDs: string[]) {
  const removalPromises = runtimeIDs.map((runtimeID) => {
    return exec("xcrun", ["simctl", "runtime", "delete", runtimeID], {});
  });
  return Promise.all(removalPromises);
}

export async function renameIosSimulator(udid: string | undefined, newDisplayName: string) {
  if (!udid) {
    return;
  }

  return await exec("xcrun", [
    "simctl",
    "--set",
    getOrCreateDeviceSet(udid),
    "rename",
    udid,
    newDisplayName,
  ]);
}

export async function removeIosSimulator(udid: string | undefined, location: SimulatorDeviceSet) {
  if (!udid) {
    return;
  }

  let deviceSetArgs: string[] = [];
  if (location === SimulatorDeviceSet.RN_IDE) {
    const setDirectory = getOrCreateDeviceSet(udid);
    deviceSetArgs = ["--set", setDirectory];
  }

  return exec("xcrun", ["simctl", ...deviceSetArgs, "delete", udid]);
}

async function listSimulatorsForLocation(location?: string) {
  let deviceSetArgs: string[] = [];
  if (location) {
    deviceSetArgs = ["--set", location];
  }
  try {
    const { stdout } = await exec(
      "xcrun",
      ["simctl", ...deviceSetArgs, "list", "devices", "--json"],
      { allowNonZeroExit: true }
    );
    const parsedData: SimulatorData = JSON.parse(stdout);

    const { devices: devicesPerRuntime } = parsedData;

    return Object.entries(devicesPerRuntime);
  } catch (e) {
    // ignore errors because some locations might not exist
  }
  return [];
}

export async function listSimulators(location: SimulatorDeviceSet): Promise<IOSDeviceInfo[]> {
  let devicesPerRuntime;
  if (location === SimulatorDeviceSet.RN_IDE) {
    const deviceSetLocation = getOrCreateDeviceSet();

    devicesPerRuntime = await listSimulatorsForLocation(deviceSetLocation);

    const oldDeviceSetLocation = getOldDeviceSetLocation();
    const oldDevicesPerRuntime = await listSimulatorsForLocation(oldDeviceSetLocation);

    devicesPerRuntime = devicesPerRuntime.concat(oldDevicesPerRuntime);
  } else {
    devicesPerRuntime = await listSimulatorsForLocation();
  }

  const runtimes = await getAvailableIosRuntimes();

  const simulators = devicesPerRuntime
    .map(([runtimeID, devices]) => {
      const runtime = runtimes.find((item) => item.identifier === runtimeID);

      return devices
        .map((device) => {
          if (!device.deviceTypeIdentifier) {
            return undefined;
          }
          return {
            id: `ios-${device.udid}`,
            platform: DevicePlatform.IOS as const,
            UDID: device.udid,
            modelId: device.deviceTypeIdentifier,
            systemName: runtime?.name ?? "Unknown",
            displayName: device.name,
            deviceType: device.deviceTypeIdentifier.includes("iPad")
              ? DeviceType.Tablet
              : DeviceType.Phone,
            available: device.isAvailable ?? false,
            runtimeInfo: runtime,
          };
        })
        .filter((e) => e !== undefined);
    })
    .flat();
  if (location === SimulatorDeviceSet.RN_IDE) {
    await ensureUniqueDisplayNames(simulators);
  }
  return simulators;
}

export enum SimulatorDeviceSet {
  Default,
  RN_IDE,
}

export async function createSimulatorWithRuntimeId(
  deviceTypeId: string,
  displayName: string,
  runtimeId: string,
  deviceSet: SimulatorDeviceSet
) {
  Logger.debug(`Create simulator ${deviceTypeId} with runtime ${runtimeId}`);

  let locationArgs: string[] = [];
  if (deviceSet === SimulatorDeviceSet.RN_IDE) {
    const deviceSetLocation = getOrCreateDeviceSet();
    locationArgs = ["--set", deviceSetLocation];
  }

  // create new simulator with selected runtime
  const { stdout: UDID } = await exec("xcrun", [
    "simctl",
    ...locationArgs,
    "create",
    displayName,
    deviceTypeId,
    runtimeId,
  ]);

  return UDID;
}

export async function createSimulator(
  deviceTypeId: string,
  displayName: string,
  runtime: IOSRuntimeInfo,
  deviceSet: SimulatorDeviceSet
) {
  const UDID = await createSimulatorWithRuntimeId(
    deviceTypeId,
    displayName,
    runtime.identifier,
    deviceSet
  );
  return {
    id: `ios-${UDID}`,
    platform: DevicePlatform.IOS,
    UDID,
    modelId: deviceTypeId,
    systemName: runtime.name,
    displayName: displayName,
    available: true, // assuming if create command went through, it's available
    runtimeInfo: runtime,
  } as IOSDeviceInfo;
}

function getDeviceSetLocation(deviceUDID?: string) {
  const appCachesDir = getAppCachesDir();
  const deviceSetLocation = path.join(appCachesDir, "Devices", "iOS");
  if (!deviceUDID) {
    return deviceSetLocation;
  }
  const oldDeviceSetLocation = getOldDeviceSetLocation();
  if (!fs.existsSync(oldDeviceSetLocation)) {
    return deviceSetLocation;
  }
  const devices = fs.readdirSync(oldDeviceSetLocation);
  if (devices.includes(deviceUDID)) {
    return oldDeviceSetLocation;
  }
  return deviceSetLocation;
}

function getOldDeviceSetLocation() {
  const oldAppCachesDir = getOldAppCachesDir();
  return path.join(oldAppCachesDir, "Devices", "iOS");
}

export function getOrCreateDeviceSet(deviceUDID?: string) {
  let deviceSetLocation = getDeviceSetLocation(deviceUDID);
  if (!fs.existsSync(deviceSetLocation)) {
    fs.mkdirSync(deviceSetLocation, { recursive: true });
  }

  return deviceSetLocation;
}

function convertToSimctlSize(size: DeviceSettings["contentSize"]): string {
  switch (size) {
    case "xsmall":
      return "extra-small";
    case "small":
      return "small";
    case "normal":
      return "medium";
    case "large":
      return "large";
    case "xlarge":
      return "extra-large";
    case "xxlarge":
      return "extra-extra-large";
    case "xxxlarge":
      return "extra-extra-extra-large";
  }
}

async function ensureUniqueDisplayNames(devices: IOSDeviceInfo[]) {
  const devicesByDisplayName = _.groupBy(devices, "displayName");
  const uniqueNames = new Set<string>(Object.keys(devicesByDisplayName));

  for (const [displayName, devicesWithSameName] of Object.entries(devicesByDisplayName)) {
    let duplicateCounter = 1;
    let newName = `${displayName} (${duplicateCounter})`;
    for (const device of devicesWithSameName.slice(1)) {
      while (uniqueNames.has(newName)) {
        duplicateCounter += 1;
        newName = `${displayName} (${duplicateCounter})`;
      }
      uniqueNames.add(newName);
      device.displayName = newName;
      await renameIosSimulator(device.UDID, newName);
    }
  }
}

export class IosSimulatorProvider implements DevicesProvider<IOSDeviceInfo>, Disposable {
  constructor(
    private stateManager: StateManager<DevicesByType>,
    private outputChannelRegistry: OutputChannelRegistry
  ) {}

  public async listDevices() {
    let shouldLoadSimulators = Platform.OS === "macos";

    if (!shouldLoadSimulators) {
      return [];
    }

    if (!(await checkXcodeExists())) {
      Logger.debug("Couldn't list iOS simulators as XCode installation wasn't found");
      return [];
    }

    try {
      const simulators = await listSimulators(SimulatorDeviceSet.RN_IDE);
      this.stateManager.updateState({ iosSimulators: simulators });
      return simulators;
    } catch (e) {
      Logger.error("Error fetching simulators", e);
      return [];
    }
  }

  public async acquireDevice(
    deviceInfo: IOSDeviceInfo,
    deviceSettings: DeviceSettings
  ): Promise<IosSimulatorDevice | undefined> {
    if (deviceInfo.platform !== DevicePlatform.IOS) {
      return undefined;
    }

    if (Platform.OS !== "macos") {
      throw new Error("Invalid platform. Expected macos.");
    }

    const simulators = await listSimulators(SimulatorDeviceSet.RN_IDE);
    const simulatorInfo = simulators.find((device) => device.id === deviceInfo.id);
    if (!simulatorInfo || simulatorInfo.platform !== DevicePlatform.IOS) {
      throw new Error(`Simulator ${deviceInfo.id} not found`);
    }
    const device = new IosSimulatorDevice(
      deviceSettings,
      simulatorInfo.UDID,
      simulatorInfo,
      this.outputChannelRegistry
    );

    if (await device.acquire()) {
      return device;
    }

    device.dispose();
    throw new DeviceAlreadyUsedError();
  }

  public dispose() {
    this.stateManager.dispose();
  }
}
